fix(rest): a sandboxed crash outranks the declared-code arm and answers the sanitised 500 UNCLASSIFIED_FAULT (#15071) - #17228
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…ndbox-crash-outranks-declared-code
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 498f494ebb0ce43e0c83adf8243dd0b40e460919 && git checkout 498f494ebb0ce43e0c83adf8243dd0b40e460919
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 470746ae4511db2f7c754d8aa6c247c3ce2120c4 5b342a17fd684f199d5e3df540e12918e7b0585d && git checkout -B drift-repro 470746ae4511db2f7c754d8aa6c247c3ce2120c4 && git merge --no-ff 5b342a17fd684f199d5e3df540e12918e7b0585d
node scripts/docs-audit/affected-docs.mjs --json 470746ae4511db2f7c754d8aa6c247c3ce2120c4 |
The level axis (#16055) refuses a clause-\xe2\x91\xa1 PR that grades every package it moves at patch. The declaration is the maintainer's (batch #27), so the level was the wrong half: @objectstack/rest goes to minor, and the BREAKING banner carries the breaking-ness the launch window keeps off the level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Contract review at
|
|
Clear-and-hold provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
Contract review F1: the "What this deliberately does NOT decide" block in `armSentence`'s docblock still described the pre-#15071 world, and after this PR every clause of it was false. It said a sandboxed CRASH reaches an arm and is answered at the arm's own declared status, that the divergence against the unwrap door's sanitised 500 is UNCHANGED, that the pin records it as an accepted divergence, and that it "carries its own decision card" -- while the card is this one and it has been executed: the crash terminal now sits above the code-gated arms, so no crash reaches this function on either door. Rewritten as a cross-reference rather than a second statement of the ruling. `isSandboxCrash`'s own docblock carries the maintainer ruling, its fence and its negative control; a file that states one rule twice is the drift this finding is made of, so the block now points there and stops. The second paragraph keeps the surviving divergence visible: what converged is the no-declared-status case. A crash that DECLARED a 4xx still leaves `resolveErrorResponse` at that status with the QuickJS wrapper, through a passthrough this card did not touch -- pinned as an ACCEPTED DIVERGENCE in `error-response-structured-arm-door-parity.test.ts`. The prose must not read as "all divergence is gone", because it is not. Comment lines only -- no executable byte moves. Proven at parser level: both revisions re-printed with `removeComments: true` hash identically (sha256 5daab82cdd23e0b93a1dfb420b2a9e3c83786975248504bdbf9907b321005fa7), with a control leg that flips one identifier and is correctly rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
ACCEPT — F1 delivered, armed for the queueDispatching seat ( F1 — the finding the tier held the ready flip forThe contract review's F1: Rewritten (now
Comment-only, proven three ways
⭐ Method note worth keeping: a first attempt used Check readings on the new head
Clause-② — stated rather than re-run
|
Fixes #15071
Clause-②: yes
Executes the maintainer ruling recorded at
5535780469— 2026-09-04, decisionbatch #27, verbatim 「同意」 on option B: a sandboxed crash outranks the
declared-code arm and reaches the unwrap door's sanitised
500 UNCLASSIFIED_FAULTwhatever code it declares. ⛔ Nothing here re-opens orre-argues that ruling.
The change, in one edit
classifyDataError's crash terminal moved — it is asked at the top of thefunction instead of inside the sandbox unwrap door, so it now outranks the
code-gated arms that are consulted before that door. One named predicate,
isSandboxCrash=isSandboxOriginANDisScriptFaultMessage, the two readssandboxBusinessMessagealready makes, asked from the other side.⛔ The terminal is not duplicated: there is still exactly one
isScriptFaultMessagegate on this path, and a pin asserts that (option C wasrefused for adding a mechanism, so the fix must not smuggle one in).
hook 'guard' threw: TypeError: …500INTERNAL_ERROR, sanitised500INTERNAL_ERROR, sanitisederror.messageThe arm list, RE-DERIVED from the tree
⛔ Not copied from #14704. Read off
structuredCodeAnswerplus the arms inclassifyDataErrorabove the unwrap door, at this PR's head: thirteendeclared-code literals sit above that door. Ten are reachable by a sandboxed
producer and are the rows this PR pins one by one —
DELETE_RESTRICTED·CONCURRENT_UPDATE·ERR_DATASOURCE_UNAVAILABLE·VALIDATION_FAILED·FEEDS_DISABLED·FILES_DISABLED·ATTACHMENT_PARENT_ACCESS·ATTACHMENT_DELETE_DENIED·RECORD_NOT_ACCESSIBLE·PERMISSION_DENIED— and three are not, each for a reason the source states in the arm itself:
DUPLICATE_RECORD(gated onname === 'DuplicateRecordError', andSandboxErrorsetsnameunconditionally),OBJECT_NOT_FOUNDandINVALID_FIELD(both carry #14541's!isSandboxOriginclause). Thename-gated limbs (
ConcurrentUpdateError,ValidationError,PermissionDeniedError) and the[Security] Access deniedmessage limb areunreachable from a sandbox producer for the same
namereason.The re-derivation is now mechanical, not a reading that rots: §4-derivation
scans the source for every declared-code literal above the unwrap door and
fails unless each is either covered per-arm above or named in an
UNREACHABLE_BY_A_SANDBOX_PRODUCERlist with its reason.The pin, flipped
error-response-sandbox-arm-message.test.ts§4 goes from ACCEPTEDDIVERGENCE to CONVERGED, with the ruling quoted beside it — the same
discipline PR #15065 used on its own §4. ⛔ The section is not deleted: it is
the only thing that would notice the divergence coming back. Three legs:
codeassertedtogether (ADR-0112), plus
not.toHavePropertyon every structured field thearm used to ship, because the sanitised terminal says status and code and
nothing else;
reaches the same sanitised 500. It was the control leg before and it is one
after;
ordinary declared refusal (a hook that throws a business error carrying a
code and does not crash) is completely unaffected, and so is a
non-sandbox producer on the same code — 「only the crash branch moves」.
Evidence
Targeted suite.
pnpm --filter @objectstack/rest test—Test Files 185 passed (185) · Tests 3108 passed (3108),VERDICT command-exit 0.pnpm --filter @objectstack/rest typecheck—VERDICT command-exit 0(
tsc --noEmitpluscheck:test-typecheck: 0 files / 0 errors). Dependencyclosure built first (
pnpm --filter '@objectstack/rest^...' build, exit 0).Ablation — directions predicted in writing before any leg ran, mutation
proven on disk by occurrence count and blob hash, restore proven by
git checkout HEAD -- PATH+ blob equality + emptygit diff HEAD+ emptygit status --porcelain, whole script undertrap … EXIT INT TERMwithabsolute paths.
isSandboxCrashguard14 failed / 106 passed— exactly the 10 flip rows, the positive control, the "asked ONCE" leg and the 2 door cases; 0 negative-control rows failed83 failed / 37 passed— the 10 flip rows are not among the failures; both negative-control families, §1/§2/§3 and the positive control all fired9aab3df05173122a3358ab1f85b4f4d58e494db6= HEAD's,git diff HEADclean, porcelain emptyLeg 2 is the leg that matters: it is the plausible-but-wrong implementation, it
turns the headline condition green, and the negative control is what catches
it. Leg 1's positive control also reddens, as predicted — the fix MOVED the
only
isScriptFaultMessagegate on this path, so removing the guard leaves acodeless crash unguarded too; ⛔ read leg 1 as "the pin can fail", not as
"pre-fix behaviour".
The pin tests import
./error-response.js— a relative import inside thepackage — so the subject resolves through the package's own TypeScript source
and no
dist/sits between the mutation and the run.Gates. Derived on the final tree with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(58 commands, no stale-tree warning, change set = the 4 paths of this PR).
Every exit code captured by redirect-then-
$?, ⛔ never through a pipe.Re-verification after the changeset round (head
d4f4a329b5). The familyre-derives identical (58 commands, byte-for-byte the same list) on the new
head. The delta from the fully-verified head
84c0bb86b8is one file —git diff 84c0bb86b8..HEAD --name-onlyfiltered of.changeset/is EMPTY — sono source or test byte moved and the source-reading families cannot change
verdict. Declared narrowing rather than a silent one: on the new head I re-ran
every family whose INPUT this commit moved (
check-changeset-no-majorwith theevent payload,
check-adr-0087-registration,check-empty-changeset,check:changeset-gate-self-tests,check:objectui-changeset,check-changeset-fixed,check:published-files,check:doc-authoring,check:nul-bytes, and both changeset gates' own--self-testbatteries) plusthe repo-wide
pnpm lint— all exit 0 — and did not re-run the built-treeratchets (
check:type-check-debt,check:dual-build-cjs-loads), which readdist/and source this commit does not touch and which were green on84c0bb86b8. CI runs the whole farm on this head regardless.Docs-drift round — the hand sweep is the round. The bot derived 2 anchors
and found no page naming them, and its own output says that is not a clean bill
of health: this change moves published REST wire behaviour, and any page that
documents the rule states it by its inputs (error codes, refusal shapes),
sharing no identifier with
isSandboxCrash. So the anchored zero carries almostno information here and the sweep below is what the round rests on.
Swept the 195 hand-written pages for a page naming any of the ten arm codes
AND crash/fault vocabulary (
UNCLASSIFIED_FAULT,INTERNAL_ERROR,TypeError,crash,threw:,SandboxError): 11 pages, each read andjudged. Three are
content/docs/releases/**— historical records, ⛔ neveredited in a code PR, and a past release note is not made false by a later
change. Of the eight live pages:
content/docs/api/error-catalog.mdx— the page the change makes more TRUE,and judged rather than skipped.
DELETE_RESTRICTED's stated cause ("otherrecords depend on it") is what a crash declaring that code contradicted; the
crash no longer arrives under it.
INTERNAL_ERROR's stated cause ("anunexpected server-side error") is exactly a crashed hook. Its crash-vs-rejection
table is scoped to
/api/v1/actionsand already says aTypeErroris a crash(500) — the
/datadoor now agrees with it. No row goes stale, so no edit.content/docs/automation/hook-bodies.mdx— "an error your body lets escape …keeps
code" is about a deliberate throw or re-throw; a crash is not that,and
isScriptFaultMessage's accepted cost (A rawTypeError: not a functionleaks in the 400 envelope for{"title": 12345}— nocode, nofields[]#7543) already governed it. No edit.content/docs/protocol/kernel/error-handling.mdx— documentsINTERNAL_ERRORas 500 (unchanged) and recommends alerting when its rate exceeds a threshold.
deployment with crashing hooks that previously answered 4xx will see that rate
rise. The sentence is a monitoring recommendation, not a statement of runtime
behaviour, and a crashing hook is precisely what it should alert on — editing a
threshold on the strength of this change would be a guess, so it is reported
here instead.
content/docs/api/client-sdk.mdx(anINTERNAL_ERRORcatalog row plus "500crash" as an existing category),
content/docs/automation/approvals.mdx(a hostprocess crash; a request-shape table for a non-sandbox producer),
content/docs/automation/flows.mdx(sandbox=actionType;crash=exactly-once resume),
content/docs/protocol/kernel/http-protocol.mdx(
sandbox= the environment enum) andcontent/docs/ui/forms.mdx(the publicform route's own validator refusals) — none states a rule about a sandboxed
body that declares a code and then crashes. No edit.
docs/is invisible to the tool on this run and on any run — it walkscontent/docsonly — so it was swept by hand with the same input-shaped query:18 files name an arm code, and the only one that also names the fault
terminal is
docs/qa/platform-checklist/areas/attachments-storage.json, whoseanalysis is about
ERR_FILE_CONSTRAINT— a producer with no arm, not a sandboxorigin, reaching the terminal by having no branch and no declared status. My
guard fires only for a sandbox crash, so that reading is unchanged.
docs/adr/is a governed surface and carries nothing about this rule (ADR-0112's
SandboxErrorlimb is the tenant-authored-code 4xx, which a crash was neverpart of).
⇒ No documentation edit is owed, judged page by page rather than inferred
from the bot's silence.
Single-writer, re-measured here rather than inherited. 23 open PRs
enumerated from the open PR list, each read against its own merge base (the
/pulls/{n}/filesdiff): 329 changed-file rows, positive control 65.changeset/rows so the scan demonstrably reads file lists, and zero rowsname
error-response. The onlypackages/restPR is #16950(
import-runner.ts,rest-server-canonical-query-ast.test.ts) — it importsisEngineDuplicateRecordEnvelopefrom this file and ⛔ does not modify it.What this PR deliberately does NOT do — and the residue it names
developerMessagechannel is not widened. The sanitised terminalemits what A raw
TypeError: not a functionleaks in the 400 envelope for{"title": 12345}— nocode, nofields[]#7543 already emits for a fault: status,INTERNAL_ERROR, noprose from the crash.
resolveErrorResponsedeclines the shared consult for a sandbox-origin error(Routes exiting through
handleRouteErroranswer the engine'sDUPLICATE_RECORDenvelope fromresolveErrorResponse's.statuspassthrough —fieldandobjectdropped,mapDataError's structured 409 arms never consulted #14541), so none of the arms the ruling names is reachable there; its answerfor a crash comes from the declared-status passthrough. For a crash that
declared a 4xx
status, that door still answers the declared status with thewrapper — the shape
rest-hook-refusal-message-parity.test.ts§7 alreadyrecords as MEASURED AND NOT REPAIRED, whose population this card widens
(the shape used to agree at both doors). Two new cases in
error-response-structured-arm-door-parity.test.ts§4 state it: CONVERGEDwhere the crash declared no status (both doors 500), ACCEPTED DIVERGENCE
where it declared one. ⛔ Green on both sides of the fix: they document the
gap, they do not bless it. Whether the ruling's phrase "classified as a
fault at both doors" means the file's two doors or the arm-vs-unwrap-door
pair of the card's own divergence table is the one question this PR leaves to
the contract-review tier — the execution constraints name only the single
door's arms and the single door's pin, so that is what shipped.
验收备注
The seven conditions triage transcribed from the ruling block at
5578501363, copied verbatim as instructed (「承接者请照抄进## 验收备注」),each answered. ⛔ None is re-argued.
Met. One PR, four files:
error-response.ts, its two pin files, and thechangeset. §4 is flipped with the ruling quoted beside it in the section's
docblock, and the file header's §4 line is rewritten to match.
Met. The case survives verbatim in behaviour and is re-labelled "the
positive control STAYS". Ablation leg 2 shows it can still fire, so it is a
control and not a decoration.
Met, and ablated. §4-negative asserts it per arm for both a sandboxed
business refusal and a non-sandbox producer, plus a row proving a crash-SHAPED
sentence from a producer that never went through the sandbox is not a sandbox
crash. Ablation leg 2 implements exactly the "凡带码即降级为 500" mistake: the
flip legs stay green and 83 tests redden.
Met. Re-derived above (13 literals, 10 reachable, 3 unreachable with
reasons), and made mechanical by §4-derivation so the next arm cannot slip past
silently. The count coincides with #14704's ten; the list was read off the tree,
not carried over.
Met. No
developerMessageemission is added or moved; the crash answer isUNCLASSIFIED_FAULT()unchanged.Met.
.changeset/sandbox-crash-outranks-declared-code-arm.mdleads withthat sentence as its title, in operator language, and states what does not
change beside it.
Met by measurement, with one premise corrected. No open PR writes this file⚠️ The parenthetical does not verify: on this tree
(23 PRs, each against its own merge base, positive control fires — see
Evidence).
error-response.ts's header carries no single-writer clause —grep -i writerover the whole file returns two hits, both the phrase "envelopewriter", neither a serial-constraint declaration. The constraint was honoured by
re-measuring the board; it is not readable from the file. Noted, not filed.
The changeset level, and the
**BREAKING**bannerCheck Changesetwas red at head84c0bb86b8on its level axis (#16055):this PR declares clause-②
yesand graded every package it moves atpatch.Path 1 of the two the gate names — the declaration is right (it is the
maintainer's,
5535780469: "published REST wire behaviour changes on a realpopulation", ⛔ not mine to revisit), so the level was the wrong half.
@objectstack/restis nowminor.The banner is owed, and is here. The launch-window convention refuses
majorwhile the fixed group versions in lockstep, so the level carries nobreaking-ness signal and the
**BREAKING**banner plus the ADR-0087 dispositionare the only ones there are. The act qualifies on the repo's own test: the answer
a published door gives moves for existing inputs — a client branching on
error.codefor the affected shape now falls to its 5xx path instead of itsrefusal path — while no export, signature or declared type changes. That is the
shape
client-get-active-member-names-the-organisation.mdmarks in the samewords, and PR #17209 marked the same way today. ⛔ Not marking it would be
exactly the erosion #13080 records: a genuinely breaking change nudged away from
the marker because the marker is inconvenient.
The banner makes
check-adr-0087-registration.mjsdemand a disposition, so thechangeset carries
not-required (no-migration-prescription)with the other fourcategories closed on facts in its own text — nothing authorable moves, so
objectstack migrate metahas nothing to visit and the changeset ships norewrite instructions.
Reproduced locally before pushing, exit codes by redirect-then-
$?:check-changeset-no-major.mjs --base origin/main --event EVENTd4f4a329b5LEVEL AXIS: ✓ … no package … is graded patchcheck-adr-0087-registration.mjs --base origin/mainA bare local run cannot reproduce this axis at all (
LEVEL AXIS: NOT APPLICABLE — no pull_request payload), so the reproduction was driven throughthe gate's own offline door,
--eventwith this PR's live payload.repaired here. CI printed
carrier: needs:contract-review is not on this PR (0 label(s) read)and called it a disagreement with theClause-②: yesline. Itis not: a sibling step in the same job enumerated five labels including
needs:contract-review119 ms earlier, and the local run above — driven from apayload that carries the labels — prints
carrier: needs:contract-review IS on this PR. The red was path 1 (the changeset level) throughout. ⛔ Do notread that line as an invitation to path 2, which would strip a maintainer-ruled
contract-review requirement off this PR to clear a level error.
Why this PR PARKS
⛔ Draft, ⛔ auto-merge not armed,
needs:contract-reviewcarried.Clause-②: yesis the ruling's own words (5535780469: "published REST wire behaviourchanges on a real population … contract-review tier;
needs:contract-reviewoncard and PR"), re-hung on the card by the claim at
5606751087. Waiting outsidethe queue is the safe state until a contract-review PASS is on file.
Generated by Claude Code